<!--
 * @Author: LiMG
 * @Date: 2024-04-09 09:30:36
 * @Description: 文件上传
-->
<script setup lang="ts" name="FileUpload">
import { ref, computed } from "vue";
import { ElMessage } from "element-plus";
import * as SparkMD5 from "spark-md5";
import axios from "axios";
import { View, Delete } from '@element-plus/icons-vue';
// 文件桶名称
const bucketName = ref("test-bucket");
// 文件预览地址
let filePreviewUrl = ref("");
// 文件预览类型
let filePreviewType = ref("");
// 显示预览弹窗
let showPreviewDialog = ref(false);
// 文件分片大小，默认5M（不能设置低于5M，否则会报错，因为MinIO要求的最小分片文件不能小于5M，并且这个值在8.5.4版本中是固定的值，不可更改）
const chunkSize = 1024 * 1024 * 5;
// 要上传的文件索引
let uploadFileIndex = ref(0);
// 已上传的文件大小
let uploadFileSize = ref(0);
// 文件信息对象
interface FileProperties {
    // 文件名
    name: string;
    // 文件大小
    size: Number;
    // 上传进度
    progress: Number;
    // 状态
    status: string;
    // 上传状态
    uploadStatus: string;
}
// 文件列表
const uploadFileList = ref<FileProperties[]>([]);
// 文件处理状态
const FileStatus = ref({
    wait: "等待上传",
    getMd5: "校验MD5",
    createChunk: "创建文件分片",
    uploading: "正在上传",
    instantUploading: "文件已存在，调用秒传方法",
    success: "上传成功",
    instantSuccess: "文件秒传成功",
    error: "上传错误",
    deal: "文件接收成功，服务器正在处理",
});
// 表格标题
const tableHeader = ref(["文件名", "文件大小", "上传进度", "操作信息", "文件操作"]);
// 文件发生变化处理方法
const handleFileChange = (file: any, fileList: any) => {
    // 处理文件变化
    initFileProperties(file);
    uploadFileList.value = fileList;
};
// 文件移除处理方法
const removeHandler = (_file: any, fileList: any) => {
    uploadFileList.value = fileList;
};
// 初始化文件信息
const initFileProperties = (file: any) => {
    // 初始化文件信息
    file.chunkList = [];
    file.status = FileStatus.value.wait;
    file.uploadStatus = "warning";
    file.progress = 0;
};
// 处理文件大小
const handleFileSize = (size: number) => {
    if (!size) {
        return "0B";
    }
    // B
    const unitSize = 1024;
    if (size < unitSize) {
        return size + " B";
    }
    // KB
    if (size < Math.pow(unitSize, 2)) {
        return (size / unitSize).toFixed(2) + " K";
    }
    // MB
    if (size < Math.pow(unitSize, 3)) {
        return (size / Math.pow(unitSize, 2)).toFixed(2) + " MB";
    }
    // GB
    if (size < Math.pow(unitSize, 4)) {
        return (size / Math.pow(unitSize, 3)).toFixed(2) + " GB";
    }
    // TB
    return (size / Math.pow(unitSize, 4)).toFixed(2) + " TB";
};
// 控制上传按钮是否可用
const uploadBtnStatus = computed(() => {
    if (uploadFileIndex.value > 0) {
        return true;
    } else {
        return false;
    }
});
// 上传文件
const uploadHandler = async () => {
    if (uploadFileList.value.length === 0) {
        ElMessage.warning("请选择文件");
        return;
    }
    if (uploadFileIndex.value >= uploadFileList.value.length) {
        ElMessage.success("上传完成");
        // 重置文件上传索引
        uploadFileIndex.value = 0;
        return;
    }
    // 初始化文件上传大小
    uploadFileSize.value = 0;
    // 开始上传
    const fileObj: any = uploadFileList.value[uploadFileIndex.value];
    // 获取文件对象大小
    const fileSize = fileObj.raw.size;
    // 获取文件的MD5值
    getFileMD5(fileObj, async (md5: string) => {
        // 校验文件是否存在
        const flag = await checkFileExist(md5);
        if (flag) {
            // 文件存在，调用秒传
            // 文件已存在，调用秒传方法
            uploadInstant(fileObj, md5, () => {
                // 设置文件上传索引
                uploadFileIndex.value += 1;
                // 递归执行下一个文件上传
                uploadHandler();
            });
        } else {
            // 判断文件大小，进行分片上传或者普通上传
            if (fileSize > chunkSize) {
                // 文件大于5M进行分片上传
                fileUploadChunk(fileObj, md5);
            } else {
                // 文件小于等于5M进行普通上传
                fileUpload(fileObj, md5);
            }
        }
    });

};
// 校验文件是否存在
const checkFileExist = async (md5: string) => {
    const flag = await axios.get(`http://localhost:8899/file/checkFileMd5/${md5}`).then(async (res: any) => {
        if (!res.data || res.data.code !== 200) return false;
        // 文件存在标识
        return res.data.result;
    });
    return flag;
};
// 文件分片上传方法
const fileUploadChunk = async (fileObj: any, md5: string) => {
    // 文件不存在，进行分片上传
    const chunkList: any = fileChunk(fileObj, chunkSize);
    // 定义一个数组接收每片文件上传的结果
    let promises = [];
    // 获取分片上传的索引
    let uploadChunkIndex: any = await getUploadChunkIndex(md5, bucketName.value);
    if (uploadChunkIndex !== 0) {
        fileObj.status = '检测到上传中断，正在恢复上传';
        // 设置已上传文件大小
        uploadFileSize.value = chunkSize * uploadChunkIndex;
        fileObj.progress = +(uploadFileSize.value / fileObj.raw.size * 100).toFixed(2);
        // 暂停进程1秒
        await new Promise((resolve) => {
            setTimeout(resolve, 1000);
        });
    }
    // 循环每片文件上传
    for (let i: any = uploadChunkIndex; i < chunkList.length; i++) {
        // 上传分片文件
        let result = await uploadChunk(fileObj, md5, i, chunkList);
        promises.push(result);
    }
    // 当所有分片上传成功后，进行下一个文件上传
    Promise.all(promises).then(() => {
        const saveObj: any = promises.filter((item: any) => item && item.id)[0];
        // 设置文件ID
        fileObj.id = saveObj.id;
        // 设置文件存储桶名称
        fileObj.bucketName = saveObj.bucketName;
        // 设置文件名称
        fileObj.objectName = saveObj.objectName;
        // 上传完成进行下一个文件上传，设置上传文件索引
        uploadFileIndex.value += 1;
        // 递归执行下一个文件上传
        uploadHandler();
    }).catch(err => {
        // 上传失败，打印错误信息
        console.error(err);
    });
};
// 获取分片上传的索引
const getUploadChunkIndex = async (md5: string, bucketName: string) => {
    const index = await axios.get(`http://localhost:8899/file/getUploadChunkIndex/${md5}/${bucketName}`).then(async (res) => {
        if (!res.data || res.data.code !== 200) return 0;
        return res.data.result ? res.data.result : 0;
    });
    return index;
};
// 文件普通上传方法
const fileUpload = async (fileObj: any, md5: string) => {
    // 文件不存在，调用上传
    const formData = new FormData();
    fileObj.status = FileStatus.value.uploading;
    formData.append("file", (fileObj as any).raw);
    formData.append("md5", md5);
    formData.append("bucketName", bucketName.value);
    formData.append("fileName", (fileObj as any).raw.name);
    // 普通上传
    const config = {
        headers: {
            "Content-Type": "multipart/form-data",
        },
        onUploadProgress: (e: any) => {
            const percentNum = (e.loaded / e.total) * 100;
            const percent = percentNum % 1 !== 0 ? percentNum.toFixed(2) : percentNum;
            fileObj.progress = +percent;
            if (percent === 100) {
                fileObj.status = FileStatus.value.deal;
            }
        },
    };
    await axios.post("http://localhost:8899/file/uploadBySingle", formData, config)
        .then((res) => {
            if (res.data && res.data.code === 200) {
                // 设置文件上传状态
                fileObj.status = FileStatus.value.success;
                // 设置文件ID
                fileObj.id = res.data.result.id;
                // 设置文件存储桶名称
                fileObj.bucketName = res.data.result.bucketName;
                // 设置文件对象名
                fileObj.objectName = res.data.result.objectName;
                // 设置文件上传索引
                uploadFileIndex.value += 1;
                // 递归执行下一个文件上传
                uploadHandler();
            } else {
                fileObj.status = FileStatus.value.error;
            }
        }).catch((error) => {
            console.log(error);
        });
};
// 分片上传
const uploadChunk = (fileObj: any, md5: string, chunkIndex: any, chunkList: any) => {
    return new Promise((resolve, reject) => {
        fileObj.status = `共${chunkList.length}片，正在上传第${chunkIndex + 1}片文件`;
        // 定义文件上传FormData对象
        const formData = new FormData();
        // 文件存储桶名称
        formData.append("bucketName", bucketName.value);
        // 文件对象
        formData.append("file", chunkList[chunkIndex]);
        // 文件MD5值
        formData.append("md5", md5);
        // 当前分片文件索引
        formData.append("chunk", chunkIndex);
        // 文件总分片数
        formData.append("chunks", chunkList.length);
        // 文件名
        formData.append("fileName", (fileObj as any).raw.name);
        axios.post("http://localhost:8899/file/uploadByChunk", formData, {
            headers: {
                "Content-Type": "multipart/form-data",
            },
            onUploadProgress: (e: any) => {
                // 计算上传进度
                const percentNum = ((uploadFileSize.value + e.loaded) / fileObj.raw.size) * 100;
                const percent = percentNum % 1 !== 0 ? percentNum.toFixed(2) : percentNum;
                fileObj.progress = +percent;
            },
        }).then((res) => {
            fileObj.status = `共${chunkList.length}片，第${chunkIndex + 1}片文件上传成功`;
            // 设置已上传文件大小
            uploadFileSize.value += chunkList[chunkIndex].size;
            if (chunkIndex === chunkList.length - 1) {
                // 所有分片文件上传完成，设置文件状态
                fileObj.status = FileStatus.value.success;
            }
            if (res.data && res.data.code === 200) {
                resolve(res.data.result);
            }
        }).catch((error) => {
            console.error(error);
            reject(error);
        });
    });
};
// 文件秒传
const uploadInstant = async (fileObj: any, md5: string, callback: Function) => {
    fileObj.status = FileStatus.value.instantUploading;
    // 暂停进程1秒
    await new Promise((resolve) => {
        setTimeout(resolve, 1000);
    });
    const formData = new FormData();
    formData.append("md5", md5);
    formData.append("fileName", (fileObj as any).raw.name);
    formData.append("bucketName", bucketName.value);
    axios.post('http://localhost:8899/file/uploadByInstant', formData, {
        headers: {
            "Content-Type": "multipart/form-data",
        },
    }).then(res => {
        if (res.data && res.data.code === 200) {
            // 设置文件上传状态
            fileObj.status = FileStatus.value.success;
            // 设置文件ID
            fileObj.id = res.data.result.id;
            // 设置文件存储桶名称
            fileObj.bucketName = res.data.result.bucketName;
            // 设置文件对象名
            fileObj.objectName = res.data.result.objectName;
            // 设置文件上传进度
            fileObj.progress = 100;
            callback();
        }
    });
};
// 清空文件
const clearFileHandler = () => {
    uploadFileList.value = [];
};
// 获取文件的MD5值
const getFileMD5 = (fileObj: any | Blob, callback: Function) => {
    // 设置获取文件的MD5值状态
    fileObj.status = FileStatus.value.getMd5;
    // 当前文件对象
    const file = fileObj.raw;
    // 创建一个BlobSliceReader来读取文件内容
    const blobSlice = File.prototype.slice || (File.prototype as any).mozSlice || (File.prototype as any).webkitSlice;
    const chunks = Math.ceil(file.size / chunkSize);
    let currentChunk = 0;
    let spark = new (SparkMD5 as any).ArrayBuffer();

    const loadNextChunk = () => {
        const start = currentChunk * chunkSize;
        const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
        const fileReader = new FileReader();
        fileReader.onload = function (e: any) {
            // 将读取的结果追加到MD5计算中
            spark.append(e.target.result);
            currentChunk++;

            if (currentChunk < chunks) {
                loadNextChunk();
            } else {
                // 计算并获取最终的MD5哈希值
                const md5Hash = spark.end();
                callback(md5Hash);
            }
        };
        fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
    };

    // 开始加载第一个分片
    loadNextChunk();
};
// 文件分片
const fileChunk = (fileObj: any, chunkSize: number) => {
    // 设置文件分片状态
    fileObj.status = FileStatus.value.createChunk;
    // 文件分片
    fileObj.chunkList = [];
    let chunkList = [];
    // 循环按分片大小进行文件分片
    for (let i = 0; i < fileObj.raw.size; i += chunkSize) {
        chunkList.push(fileObj.raw.slice(i, i + chunkSize));
    }
    return chunkList;
};
// 获取文件预览地址
const getFilePreviewUrl = (fileObj: any) => {
    return axios.get(`http://localhost:8899/file/getFileViewUrl/${fileObj.id}`).then((res) => {
        if (res.data && res.data.code === 200 && res.data.result) {
            return res.data.result;
        } else {
            return null;
        }
    });
};
// 文件预览
const viewFileHandler = async (fileObj: any) => {
    const previewUrl = await getFilePreviewUrl(fileObj);
    if (!previewUrl) {
        ElMessage.error("文件预览地址获取失败");
        return;
    }
    // 获取文件名后缀
    const fileNameSuffix = fileObj.name.split(".").pop();
    // 判断文件类型
    if (fileNameSuffix === "mp4" || fileNameSuffix === "mov") {
        // 视频文件预览
        filePreviewType.value = "video";
    } else if (fileNameSuffix === "mp3") {
        // 音频文件预览
        filePreviewType.value = "audio";
    } else if (fileNameSuffix === "png" || fileNameSuffix === 'jpg' || fileNameSuffix === 'gif') {
        // 图片文件预览
        filePreviewType.value = "image";
    } else {
        // 其他文件类型预览
        filePreviewType.value = "other";
        window.open(previewUrl);
        return;
    }
    filePreviewUrl.value = previewUrl;
    showPreviewDialog.value = true;
};
// 删除文件
const deleteFileHandler = (fileObj: any) => {
    const params: any = {
        id: fileObj.id,
    };
    axios.get('http://localhost:8899/file/deleteFile', { params }).then((res) => {
        if (res.data && res.data.code === 200 && res.data.result) {
            // 删除文件
            uploadFileList.value = uploadFileList.value.filter((item: any) => item.name !== fileObj.name);
            ElMessage.success("文件删除成功");
        } else {
            ElMessage.error("文件删除失败");
        }
    });
};
</script>

<template>
    <div class="file-upload">
        <div class="mian-container">
            <div class="title">上传文件</div>
            <div class="button-container">
                <el-upload ref="upload" action="#" :on-remove="removeHandler" :on-change="handleFileChange"
                    :file-list="uploadFileList" :show-file-list="false" :auto-upload="false" multiple>
                    <el-button slot="trigger" type="primary" size="small" plain>选择文件</el-button>
                    <el-button type="success" size="small" plain :disabled="uploadBtnStatus"
                        @click.stop="uploadHandler">上传</el-button>
                    <el-button type="danger" size="small" plain @click.stop="clearFileHandler">清空</el-button>
                </el-upload>
            </div>
            <div class="file-list-container">
                <div class="file-list-header">
                    <table>
                        <th v-for="(item, index) in tableHeader" :key="index">
                            {{ item }}
                        </th>
                    </table>
                </div>
                <div class="file-list">
                    <div v-for="(item, index) in uploadFileList" :key="index" class="upload-file-item">
                        <div class="file-info-item file-name" :title="item.name">
                            {{ item.name }}
                        </div>
                        <div class="file-info-item file-size">{{ handleFileSize(+item.size) }}</div>
                        <div class="file-info-item file-progress">
                            <span class="file-progress-label"></span>
                            <el-progress :percentage="item.progress" :stroke-width="16" :text-inside="true"
                                status="success" class="file-progress-value" />
                        </div>
                        <div class="file-info-item file-status">
                            {{ item.status }}
                        </div>
                        <div class="file-info-item file-operate">
                            <el-button v-if="item.status === '上传成功'" type="primary" :icon="View" circle
                                @click="viewFileHandler(item)" />
                            <el-button v-if="item.status === '上传成功'" type="primary" :icon="Delete" circle
                                @click="deleteFileHandler(item)" />
                            <span v-else>-</span>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <el-dialog v-model="showPreviewDialog" v-if="showPreviewDialog" :close-on-click-modal="false" width="600"
            height="350" title="文件预览">
            <div class="preview-container">
                <video v-if="filePreviewType === 'video'" autoplay controls :src="filePreviewUrl"></video>
                <audio v-if="filePreviewType === 'audio'"></audio>
                <img v-if="filePreviewType === 'image'" :src="filePreviewUrl"></img>
            </div>
        </el-dialog>
    </div>
</template>

<style scoped lang="scss">
.file-upload {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;

    .mian-container {
        width: 80%;
        height: 90%;

        .title {
            width: 100%;
            height: 30px;
            font-size: 20px;
            font-weight: 800;
            text-align: center;
            line-height: 30px;
        }

        .button-container {
            width: 100%;
            height: 30px;
            display: flex;
            align-items: center;
        }

        .file-list-container {
            width: 100%;
            height: calc(100% - 50px);
            margin-top: 20px;

            .file-list-header {
                width: 100%;
                height: 30px;
                border-bottom: 1px solid #ccc;
                font-size: 14px;

                table {
                    width: 100%;
                    height: 100%;

                    th:first {
                        width: 35%;
                    }

                    th:nth-child(2) {
                        width: 10%;
                    }

                    th:nth-child(3) {
                        width: 20%;
                    }

                    th:nth-child(4) {
                        width: 20%;
                    }

                    th:last-child {
                        width: 15%;
                    }
                }
            }

            .file-list {
                width: 100%;
                height: calc(100% - 50px);
                overflow: auto;

                .upload-file-item {
                    width: 100%;
                    height: 40px;
                    display: flex;
                    align-items: center;
                    font-size: 14px;

                    .file-info-item {
                        text-align: center;
                        height: 100%;
                        line-height: 40px;
                        border-bottom: 1px solid #ccc;
                    }

                    .file-name {
                        width: 35%;
                    }

                    .file-size {
                        width: 10%;
                    }

                    .file-progress {
                        width: 20%;
                        display: flex;
                        align-items: center;
                        justify-content: center;

                        .file-progress-value {
                            width: 90%;
                        }
                    }

                    .file-status {
                        width: 20%;
                    }

                    .file-operate {
                        width: 15%;
                        display: flex;
                        justify-content: center;
                        align-items: center;
                    }
                }
            }
        }
    }

    .preview-container {
        width: 100%;
        height: 330px;

        video {
            width: 100%;
            height: 100%;
        }

        img {
            width: 100%;
            height: 100%;
        }
    }
}

::v-deep .el-collapse-item__header {
    background: #f5f7f9;
}

::v-deep .el-collapse-item__content {
    background: #f5f7f9;
}

::v-deep .el-icon {
    margin-right: 0;
}
</style>
